diff options
Diffstat (limited to 'src/routes/projects/[projectId]/routine/[routineId]')
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts | 80 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.svelte | 440 |
2 files changed, 520 insertions, 0 deletions
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts new file mode 100644 index 0000000..83bac3c --- /dev/null +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts @@ -0,0 +1,80 @@ +import { error, fail } from '@sveltejs/kit'; +import { getById } from '$lib/server/services/projects'; +import { + edit, + complete, + loadCurrent, + loadCompletions, +} from '$lib/server/services/routines'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import { + parseIntervalToDays, + computeRoutineState, +} from '$lib/shared/routine-metrics'; +import type { Actions, PageServerLoad } from './$types'; + +function parseFrequencyDays(value: FormDataEntryValue | null): number { + const days = parseIntervalToDays(value?.toString() ?? ''); + if (days === null) { + throw new Error('Invalid interval'); + } + return days; +} + +export const load: PageServerLoad = async ({ params }) => { + const projectId = numericId(params.projectId); + const routineId = numericId(params.routineId); + + const project = await getById(projectId); + if (!project || project.archived) { + throw error(404, 'Projet introuvable'); + } + + const routine = await loadCurrent(routineId); + if (!routine || routine.archived || routine.projectId !== projectId) { + throw error(404, 'Routine introuvable'); + } + + const completions = await loadCompletions(routineId); + return { + project: { id: project.id, name: project.name }, + routine, + completions, + state: computeRoutineState(routine, completions, Date.now()), + }; +}; + +export const actions: Actions = { + edit: async ({ request, params }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + const description = data.get('description')?.toString() ?? ''; + try { + await edit(numericId(params.routineId), { + name, + frequencyDays: parseFrequencyDays(data.get('frequencyDays')), + description, + }); + } catch { + return fail(400, { error: 'Fréquence invalide' }); + } + return { success: true, type: 'edit' as const }; + }, + + complete: async ({ request, params }) => { + const data = await request.formData(); + try { + await complete( + numericId(params.routineId), + parseFormString(data.get('note')), + new Date().toISOString(), + ); + } catch { + return fail(400, { error: 'Routine introuvable' }); + } + return { success: true }; + }, +}; diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte new file mode 100644 index 0000000..ab0774c --- /dev/null +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -0,0 +1,440 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { + formatFrequency, + parseIntervalToDays, + STATUS_LABELS, + computeHealthSeries, + type RoutineState, + } from '$lib/shared/routine-metrics'; + + let { data, form } = $props(); + + let name = $state(''); + let frequency = $state(''); + let description = $state(''); + let note = $state(''); + let saved = $state(false); + + $effect(() => { + name = data.routine.name; + frequency = `${data.routine.frequencyDays}d`; + description = data.routine.description; + }); + + $effect(() => { + if (form?.success && form?.type === 'edit') { + saved = true; + const t = setTimeout(() => (saved = false), 2000); + return () => clearTimeout(t); + } + }); + + const frequencyDays = $derived(parseIntervalToDays(frequency)); + + const dateFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + }); + const dateTimeFormatter = new Intl.DateTimeFormat('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + const series = $derived( + computeHealthSeries(data.routine, data.completions, Date.now()), + ); + + const PAD = 10; + const W = 800; + const H = 140; + const geometry = $derived.by(() => { + if (series.length < 2) return null; + const minT = series[0].at; + const maxT = series[series.length - 1].at; + const span = Math.max(1, maxT - minT); + const x = (t: number) => PAD + ((t - minT) / span) * (W - 2 * PAD); + const y = (h: number) => H - PAD - (h / 100) * (H - 2 * PAD); + const points = series + .map((p) => `${x(p.at).toFixed(1)},${y(p.health).toFixed(1)}`) + .join(' '); + const grid = [0, 25, 50, 75, 100].map((h) => ({ y: y(h), label: h })); + const markers = data.completions + .map((c) => { + const t = Date.parse(c.doneAt); + return t >= minT && t <= maxT ? { x: x(t), y: y(100) } : null; + }) + .filter((m): m is { x: number; y: number } => m !== null); + return { points, grid, markers }; + }); + + function scoreClass(score: number): string { + if (score >= 80) return 'score score--good'; + if (score >= 50) return 'score score--mid'; + return 'score score--bad'; + } + + function statusClass(status: RoutineState['status']): string { + if (status === 'ok') return 'status status--ok'; + if (status === 'due') return 'status status--due'; + if (status === 'late') return 'status status--late'; + return 'status status--very-late'; + } +</script> + +<main> + <a class="back-link" href="/projects/{data.project.id}" + >← {data.project.name}</a + > + + <header class="page-header"> + <h1>{data.routine.name}</h1> + <span class={scoreClass(data.state.health)}>{data.state.health}</span> + </header> + + <form + class="form-card" + method="POST" + action="?/edit" + use:enhance={() => { + return async ({ result, update }) => { + await update({ reset: false }); + }; + }} + > + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + <label> + <span class="form-label">Nom</span> + <input + class="form-input" + type="text" + name="name" + bind:value={name} + autocomplete="off" + /> + </label> + <label> + <span class="form-label">Intervalle</span> + <input + class="form-input frequency" + class:input-invalid={frequency !== '' && frequencyDays === null} + type="text" + name="frequencyDays" + bind:value={frequency} + placeholder="2w" + autocomplete="off" + aria-label="Intervalle (ex: 1d, 2w, 3m)" + /> + </label> + <label> + <span class="form-label">Description</span> + <textarea + class="form-input desc" + name="description" + bind:value={description} + rows={3} + placeholder="Pourquoi, comment, quand..." + autocomplete="off" + ></textarea> + </label> + {#if frequency !== '' && frequencyDays === null} + <p class="error">Intervalle invalide — ex : 1d, 2w, 3m, 1y</p> + {/if} + <button + class="btn-primary" + type="submit" + disabled={!name.trim() || frequencyDays === null} + > + Enregistrer + </button> + <p class="save-status" class:visible={saved}>✓ Enregistré</p> + </form> + + <h2 class="section-title">Santé</h2> + <div class="form-card chart-card"> + {#if geometry} + <svg + class="health-chart" + viewBox="0 0 {W} {H}" + preserveAspectRatio="none" + role="img" + aria-label="Évolution de la santé de la routine dans le temps" + > + {#each geometry.grid as line (line.label)} + <line + x1={PAD} + y1={line.y} + x2={W - PAD} + y2={line.y} + class="chart-grid" + /> + {/each} + <polyline points={geometry.points} class="chart-line" fill="none" /> + {#each geometry.markers as m (m.x)} + <circle cx={m.x} cy={m.y} r="4" class="chart-marker" /> + {/each} + </svg> + <div class="chart-footer"> + <span class={statusClass(data.state.status)}> + {STATUS_LABELS[data.state.status]} + {#if data.state.overdueDays > 0}({data.state.overdueDays} j){/if} + </span> + <span class="chart-meta"> + {formatFrequency(data.routine.frequencyDays)} + {#if data.state.lastDoneAt} + · fait le {dateFormatter.format(new Date(data.state.lastDoneAt))} + {:else} + · jamais fait + {/if} + </span> + </div> + {:else} + <p class="empty">Pas encore assez d'historique pour tracer la courbe.</p> + {/if} + </div> + + <h2 class="section-title">Historique</h2> + <form + class="form-card form-card--compact add-completion" + method="POST" + action="?/complete" + use:enhance={() => { + return async ({ update }) => { + note = ''; + await update({ reset: false }); + }; + }} + > + <input + class="form-input" + type="text" + name="note" + bind:value={note} + placeholder="Note (optionnel)" + autocomplete="off" + /> + <button class="btn-done" type="submit">Fait</button> + </form> + + <ul class="item-list"> + {#each data.completions as c (c.id)} + <li class="item-row item-row--card completion-row"> + <span class="completion-date"> + {dateTimeFormatter.format(new Date(c.doneAt))} + </span> + {#if c.note} + <span class="completion-note">{c.note}</span> + {/if} + </li> + {/each} + </ul> + + {#if data.completions.length === 0} + <p class="empty">Aucune réalisation pour l'instant.</p> + {/if} +</main> + +<style> + .back-link { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-top: 1rem; + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s; + } + + .back-link:hover { + color: var(--accent); + } + + .page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 0; + } + + .page-header h1 { + overflow-wrap: anywhere; + } + + .frequency { + max-width: 8rem; + } + + .input-invalid { + border-color: var(--danger); + } + + .desc { + width: 100%; + resize: vertical; + font-family: inherit; + } + + .save-status { + font-size: 0.875rem; + color: var(--success); + font-weight: 500; + visibility: hidden; + opacity: 0; + transition: opacity 0.2s ease; + min-height: 1.25rem; + } + + .save-status.visible { + visibility: visible; + opacity: 1; + } + + .section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin: 1.5rem 0 0.5rem; + padding-left: 0.5rem; + border-left: 3px solid var(--accent); + } + + .chart-card { + align-items: stretch; + } + + .health-chart { + width: 100%; + height: 9rem; + } + + .chart-grid { + stroke: var(--border); + stroke-width: 1; + stroke-dasharray: 2 4; + } + + .chart-line { + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; + } + + .chart-marker { + fill: var(--success); + stroke: var(--white); + stroke-width: 1.5; + } + + .chart-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; + } + + .chart-meta { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .status { + flex-shrink: 0; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .status--ok { + background: var(--success); + color: var(--white); + } + + .status--due { + background: var(--shadow-accent); + color: var(--accent); + } + + .status--late { + background: var(--accent); + color: var(--white); + } + + .status--very-late { + background: var(--danger); + color: var(--white); + } + + .score { + flex-shrink: 0; + min-width: 2.75rem; + padding: 0.25rem 0.5rem; + border-radius: 9999px; + text-align: center; + font-size: 0.875rem; + font-weight: 700; + color: var(--white); + } + + .score--good { + background: var(--success); + } + + .score--mid { + background: var(--accent); + } + + .score--bad { + background: var(--danger); + } + + .add-completion { + flex-direction: row; + align-items: center; + } + + .add-completion .form-input { + flex: 1; + } + + .btn-done { + flex-shrink: 0; + padding: 0.75rem 1.25rem; + border: none; + border-radius: 0.75rem; + background: var(--success); + color: var(--white); + font-size: 0.9375rem; + font-weight: 500; + transition: all 0.2s ease; + } + + .btn-done:hover { + filter: brightness(1.05); + transform: translateY(-1px); + } + + .completion-row { + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + } + + .completion-date { + font-weight: 500; + } + + .completion-note { + font-size: 0.875rem; + color: var(--text-secondary); + } +</style> |
